\<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>录音分片上传</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
    <!-- 修正SparkMD5的引用 -->
    <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#3B82F6',
                        secondary: '#10B981',
                        danger: '#EF4444',
                    },
                    fontFamily: {
                        sans: ['Inter', 'system-ui', 'sans-serif'],
                    },
                }
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
            .content-auto {
                content-visibility: auto;
            }
            .recording-animation {
                animation: pulse 1.5s infinite;
            }
            @keyframes pulse {
                0% { transform: scale(1); }
                50% { transform: scale(1.05); }
                100% { transform: scale(1); }
            }
        }
    </style>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
    <header class="bg-white shadow-md py-4 px-6">
        <h1 class="text-2xl font-bold text-gray-800">录音分片上传演示</h1>
    </header>
    
    <main class="flex-grow container mx-auto px-4 py-8">
        <div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
            <!-- 录音控制区 -->
            <div class="mb-8 text-center">
                <h2 class="text-xl font-semibold mb-4 text-gray-700">录音控制</h2>
                <div class="flex justify-center space-x-6">
                    <button id="startRecord" class="bg-primary hover:bg-primary/90 text-white p-4 rounded-full shadow-lg transition-all">
                        <i class="fa fa-microphone text-2xl"></i>
                    </button>
                    <button id="stopRecord" class="bg-danger hover:bg-danger/90 text-white p-4 rounded-full shadow-lg transition-all hidden">
                        <i class="fa fa-stop text-2xl"></i>
                    </button>
                    <button id="playRecord" class="bg-secondary hover:bg-secondary/90 text-white p-4 rounded-full shadow-lg transition-all hidden">
                        <i class="fa fa-play text-2xl"></i>
                    </button>
                    <button id="stopPlay" class="bg-gray-600 hover:bg-gray-700 text-white p-4 rounded-full shadow-lg transition-all hidden">
                        <i class="fa fa-stop-circle text-2xl"></i>
                    </button>
                </div>
                <div id="recordingStatus" class="mt-4 text-gray-600">
                    点击麦克风开始录音
                </div>
                <div id="recordingTime" class="mt-2 text-lg font-mono text-gray-500 hidden">
                    00:00
                </div>
            </div>
            
            <!-- 上传控制区 -->
            <div id="uploadSection" class="mb-8 hidden">
                <h2 class="text-xl font-semibold mb-4 text-gray-700">文件上传</h2>
                <div class="mb-4">
                    <div class="flex justify-between mb-1">
                        <span id="uploadFileName" class="text-sm text-gray-600">准备上传...</span>
                        <span id="uploadProgressText" class="text-sm font-medium text-primary">0%</span>
                    </div>
                    <div class="w-full bg-gray-200 rounded-full h-2.5">
                        <div id="uploadProgressBar" class="bg-primary h-2.5 rounded-full" style="width: 0%"></div>
                    </div>
                </div>
                <div class="flex justify-center space-x-4 mt-4">
                    <button id="startUpload" class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg shadow transition-all">
                        <i class="fa fa-upload mr-1"></i> 开始上传
                    </button>
                    <button id="pauseUpload" class="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg shadow transition-all hidden">
                        <i class="fa fa-pause mr-1"></i> 暂停
                    </button>
                    <button id="resumeUpload" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg shadow transition-all hidden">
                        <i class="fa fa-play mr-1"></i> 继续
                    </button>
                    <button id="cancelUpload" class="bg-danger hover:bg-danger/90 text-white px-4 py-2 rounded-lg shadow transition-all hidden">
                        <i class="fa fa-times mr-1"></i> 取消
                    </button>
                </div>
            </div>
            
            <!-- 上传结果区 -->
            <div id="resultSection" class="mb-8 hidden">
                <h2 class="text-xl font-semibold mb-4 text-gray-700">上传结果</h2>
                <div id="successMessage" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded hidden">
                    <i class="fa fa-check-circle mr-2"></i>
                    <span>文件上传成功！</span>
                </div>
                <div id="errorMessage" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded hidden">
                    <i class="fa fa-exclamation-circle mr-2"></i>
                    <span id="errorText"></span>
                </div>
                <div id="fileInfo" class="mt-4 text-gray-600 hidden">
                    <p><strong>文件名：</strong><span id="resultFileName"></span></p>
                    <p><strong>文件大小：</strong><span id="resultFileSize"></span></p>
                    <p><strong>保存路径：</strong><span id="resultFilePath"></span></p>
                </div>
            </div>
        </div>
    </main>
    
    <footer class="bg-gray-800 text-white py-4 px-6">
        <div class="container mx-auto text-center text-sm">
            录音分片上传演示 · 作者:十八朵郁金香 &copy; 2025
        </div>
    </footer>

    <script>
        // 录音相关变量
        let mediaRecorder;
        let audioChunks = [];
        let audioBlob;
        let recordingTimer;
        let recordingSeconds = 0;
        let audioUrl;
        let audioPlayer = null; // 音频播放器实例
        let lastPlayTime = 0;  // 记录上次暂停时的播放位置
        
        // DOM元素
        const startRecordBtn = document.getElementById('startRecord');
        const stopRecordBtn = document.getElementById('stopRecord');
        const playRecordBtn = document.getElementById('playRecord');
        const stopPlayBtn = document.getElementById('stopPlay');

        const recordingStatus = document.getElementById('recordingStatus');
        const recordingTime = document.getElementById('recordingTime');
        const uploadSection = document.getElementById('uploadSection');
        const uploadFileName = document.getElementById('uploadFileName');
        const uploadProgressBar = document.getElementById('uploadProgressBar');
        const uploadProgressText = document.getElementById('uploadProgressText');
        const startUploadBtn = document.getElementById('startUpload');
        const pauseUploadBtn = document.getElementById('pauseUpload');
        const resumeUploadBtn = document.getElementById('resumeUpload');
        const cancelUploadBtn = document.getElementById('cancelUpload');
        const resultSection = document.getElementById('resultSection');
        const successMessage = document.getElementById('successMessage');
        const errorMessage = document.getElementById('errorMessage');
        const errorText = document.getElementById('errorText');
        const fileInfo = document.getElementById('fileInfo');
        const resultFileName = document.getElementById('resultFileName');
        const resultFileSize = document.getElementById('resultFileSize');
        const resultFilePath = document.getElementById('resultFilePath');
        
        // 确保SparkMD5已加载
        if (typeof SparkMD5 === 'undefined') {
            console.error('SparkMD5库未正确加载，请检查引用');
            alert('加载必要组件失败，请刷新页面重试');
        }
        
        // 录音上传工具类
        class AudioChunkUploader {
            constructor(options) {
                const port=3005
                // 配置参数
                this.options = {
                    chunkSize: 5 * 1024 * 1024, // 分片大小，默认5MB
                    uploadUrl: 'http://localhost:'+port+'/upload/chunk', // 分片上传接口地址
                    mergeUrl: 'http://localhost:'+port+'/upload/merge', // 合并分片接口地址
                    file: null, // 录音文件
                    fileName: '', // 文件名
                    ...options
                };
                
                // 上传状态
                this.state = {
                    totalChunks: 0, // 总分片数
                    uploadedChunks: 0, // 已上传分片数
                    fileSize: 0, // 文件总大小
                    fileMd5: '', // 文件唯一标识（用于合并）
                    isPaused: false, // 是否暂停
                    isAborted: false // 是否取消
                };
            }
            
            /**
             * 开始上传
             */
            async startUpload() {
                try {
                    if (!this.options.file) {
                        throw new Error('没有要上传的文件');
                    }
                    
                    // 1. 获取文件信息
                    this._getFileInfo();
                    
                    // 2. 计算文件MD5（用于服务端识别同一文件的分片）
                    await this._calculateFileMd5();
                    
                    // 3. 计算总分片数
                    this.state.totalChunks = Math.ceil(this.state.fileSize / this.options.chunkSize);
                    console.log(`文件大小: ${this.state.fileSize}，分片数: ${this.state.totalChunks}`);
                    
                    // 4. 逐个上传分片
                    await this._uploadChunks();
                    
                    // 5. 通知服务器合并分片
                    const mergeResult = await this._mergeChunks();
                    
                    return {
                        success: true,
                        message: '上传成功',
                        data: mergeResult
                    };
                } catch (error) {
                    console.error('上传失败:', error);
                    return {
                        success: false,
                        message: error.message || '上传失败'
                    };
                }
            }
            
            /**
             * 获取文件信息
             */
            _getFileInfo() {
                this.state.fileSize = this.options.file.size;
                this.options.fileName = this.options.fileName || `recording_${Date.now()}.webm`;
            }
            
            /**
             * 计算文件MD5（用于标识同一文件）
             */
            async _calculateFileMd5() {
                if (typeof SparkMD5 === 'undefined') {
                    throw new Error('SparkMD5库未加载，无法计算文件MD5');
                }
                
                return new Promise((resolve, reject) => {
                    const fileReader = new FileReader();
                    const spark = new SparkMD5.ArrayBuffer();
                    
                    fileReader.onload = (e) => {
                        spark.append(e.target.result);
                        this.state.fileMd5 = spark.end();
                        resolve();
                    };
                    
                    fileReader.onerror = reject;
                    fileReader.readAsArrayBuffer(this.options.file);
                });
            }
            
            /**
             * 上传所有分片
             */
            async _uploadChunks() {
                for (let chunkIndex = 0; chunkIndex < this.state.totalChunks; chunkIndex++) {
                    // 检查是否需要暂停或取消
                    if (this.state.isAborted) {
                        throw new Error('上传已取消');
                    }
                    if (this.state.isPaused) {
                        // 等待恢复上传
                        await new Promise(resolve => {
                            const check = setInterval(() => {
                                if (!this.state.isPaused) {
                                    clearInterval(check);
                                    resolve();
                                }
                            }, 100);
                        });
                    }
                    
                    // 上传当前分片
                    await this._uploadSingleChunk(chunkIndex);
                    this.state.uploadedChunks = chunkIndex + 1;
                    
                    // 触发进度回调
                    if (this.options.onProgress) {
                        this.options.onProgress({
                            percent: Math.round((this.state.uploadedChunks / this.state.totalChunks) * 100),
                            uploaded: this.state.uploadedChunks,
                            total: this.state.totalChunks
                        });
                    }
                }
            }
            
            /**
             * 上传单个分片
             * @param {number} chunkIndex 分片索引
             */
            async _uploadSingleChunk(chunkIndex) {
                const start = chunkIndex * this.options.chunkSize;
                const end = Math.min(start + this.options.chunkSize, this.state.fileSize);
                const chunk = this.options.file.slice(start, end);
                
                return new Promise((resolve, reject) => {
                    // 创建FormData
                    const formData = new FormData();
                    formData.append('chunk', chunkIndex);
                    formData.append('chunks', this.state.totalChunks);
                    formData.append('fileMd5', this.state.fileMd5);
                    formData.append('fileName', this.options.fileName);
                    formData.append('file', chunk, `${this.state.fileMd5}_${chunkIndex}.part`);
                    
                    // 上传分片
                    fetch(this.options.uploadUrl, {
                        method: 'POST',
                        body: formData
                    })
                    .then(response => response.json())
                    .then(data => {
                        if (data.success) {
                            resolve();
                        } else {
                            reject(new Error(`分片 ${chunkIndex} 上传失败: ${data.message || '未知错误'}`));
                        }
                    })
                    .catch(err => {
                        reject(new Error(`分片 ${chunkIndex} 上传失败: ${err.message}`));
                    });
                });
            }
            
            /**
             * 通知服务器合并分片
             */
            async _mergeChunks() {
                return fetch(this.options.mergeUrl, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        fileMd5: this.state.fileMd5,
                        fileName: this.options.fileName,
                        chunks: this.state.totalChunks
                    })
                })
                .then(response => response.json())
                .then(data => {
                    if (data.success) {
                        return data;
                    } else {
                        throw new Error('合并分片失败: ' + (data.message || '未知错误'));
                    }
                });
            }
            
            /**
             * 暂停上传
             */
            pause() {
                this.state.isPaused = true;
            }
            
            /**
             * 恢复上传
             */
            resume() {
                this.state.isPaused = false;
            }
            
            /**
             * 取消上传
             */
            abort() {
                this.state.isAborted = true;
            }
        }
        
        // 录音控制
        startRecordBtn.addEventListener('click', async () => {
            try {
                // 重置录音状态
                audioChunks = [];
                recordingSeconds = 0;
                
                // 获取媒体流
                const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
                
                // 创建媒体录制器
                mediaRecorder = new MediaRecorder(stream);
                
                // 开始录制
                mediaRecorder.start();
                
                // 更新UI
                startRecordBtn.classList.add('hidden');
                stopRecordBtn.classList.remove('hidden');
                recordingStatus.textContent = '正在录音...';
                recordingStatus.classList.add('text-danger', 'font-medium', 'recording-animation');
                recordingTime.classList.remove('hidden');
                
                // 开始计时
                recordingTimer = setInterval(() => {
                    recordingSeconds++;
                    const minutes = Math.floor(recordingSeconds / 60).toString().padStart(2, '0');
                    const seconds = (recordingSeconds % 60).toString().padStart(2, '0');
                    recordingTime.textContent = `${minutes}:${seconds}`;
                }, 1000);
                
            } catch (error) {
                console.error('录音开始失败:', error);
                recordingStatus.textContent = '录音权限被拒绝，请允许麦克风访问';
                recordingStatus.classList.add('text-danger');
            }
        });
        
        stopRecordBtn.addEventListener('click', () => {
            if (mediaRecorder && mediaRecorder.state !== 'inactive') {
                // 停止录制
                mediaRecorder.stop();
                
                // 停止所有音轨
                mediaRecorder.stream.getTracks().forEach(track => track.stop());
                
                // 清除计时器
                clearInterval(recordingTimer);
                
                // 更新UI
                stopRecordBtn.classList.add('hidden');
                startRecordBtn.classList.remove('hidden');
                playRecordBtn.classList.remove('hidden');
                recordingStatus.textContent = '录音已完成';
                recordingStatus.classList.remove('text-danger', 'font-medium', 'recording-animation');
                
                // 处理录制结果
                mediaRecorder.ondataavailable = (event) => {
                    audioChunks.push(event.data);
                    audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
                    audioUrl = URL.createObjectURL(audioBlob);
                    
                    // 显示上传区域
                    uploadSection.classList.remove('hidden');
                    uploadFileName.textContent = `recording_${Date.now()}.webm`;
                };
            }
        });
        
        //playRecordBtn.addEventListener('click', () => {
        //    if (audioUrl) {
        //        const audio = new Audio(audioUrl);
        //        audio.play();
        //    }
        //});
        playRecordBtn.addEventListener('click', () => {
            if (audioUrl) {
                // 如果已有播放器        
                if (!audioPlayer) {
                    audioPlayer = new Audio(audioUrl);
                    // 如果有上次暂停的位置，设置起始位置
                    if (lastPlayTime > 0) {
                        audioPlayer.currentTime = lastPlayTime;
                    }
                } else {
                    // 从上次暂停位置继续播放
                    audioPlayer.currentTime = lastPlayTime;
                }
                
                audioPlayer.play();
                
                // 更新按钮状态
                playRecordBtn.classList.add('hidden');
                stopPlayBtn.classList.remove('hidden');
                recordingStatus.textContent = '正在播放...';
            }
        });
        // 添加停止播放按钮点击事件
        stopPlayBtn.addEventListener('click', () => {
            if (audioPlayer) {
                // 记录当前播放位置
                lastPlayTime = audioPlayer.currentTime;
                audioPlayer.pause();
                
                // 不清除播放器实例，只暂停
                // audioPlayer = null; 
                
                // 更新按钮状态
                stopPlayBtn.classList.add('hidden');
                playRecordBtn.classList.remove('hidden');
                recordingStatus.textContent = `已暂停，当前位置: ${formatTime(lastPlayTime)}`;
            }
        });
        // 监听音频播放结束事件
        document.addEventListener('ended', (e) => {
            if (e.target === audioPlayer) {
                // 播放结束后重置状态
                lastPlayTime = 0;
                audioPlayer = null;
                stopPlayBtn.classList.add('hidden');
                playRecordBtn.classList.remove('hidden');
                recordingStatus.textContent = '录音已完成';
            }
        }, true);

        // 工具函数：格式化时间（秒 -> MM:SS）
        function formatTime(seconds) {
            const minutes = Math.floor(seconds / 60).toString().padStart(2, '0');
            const secs = Math.floor(seconds % 60).toString().padStart(2, '0');
            return `${minutes}:${secs}`;
        }


        // 上传控制
        let uploader;
        
        startUploadBtn.addEventListener('click', () => {
            if (audioBlob) {
                // 创建文件对象
                const file = new File([audioBlob], `recording_${Date.now()}.webm`, { type: 'audio/webm' });
                
                // 创建上传实例
                uploader = new AudioChunkUploader({
                    file: file,
                    chunkSize: 5 * 1024 * 1024, // 5MB分片
                    onProgress: (progress) => {
                        uploadProgressBar.style.width = `${progress.percent}%`;
                        uploadProgressText.textContent = `${progress.percent}%`;
                    }
                });
                
                // 更新UI
                startUploadBtn.classList.add('hidden');
                pauseUploadBtn.classList.remove('hidden');
                cancelUploadBtn.classList.remove('hidden');
                resultSection.classList.add('hidden');
                
                // 开始上传
                uploader.startUpload().then(result => {
                    if (result.success) {
                        // 上传成功
                        successMessage.classList.remove('hidden');
                        errorMessage.classList.add('hidden');
                        fileInfo.classList.remove('hidden');
                        resultFileName.textContent = result.data.fileName;
                        resultFileSize.textContent = formatFileSize(file.size);
                        resultFilePath.textContent = result.data.filePath;
                    } else {
                        // 上传失败
                        errorMessage.classList.remove('hidden');
                        successMessage.classList.add('hidden');
                        errorText.textContent = result.message;
                    }
                    
                    // 重置上传按钮状态
                    startUploadBtn.classList.remove('hidden');
                    pauseUploadBtn.classList.add('hidden');
                    resumeUploadBtn.classList.add('hidden');
                    cancelUploadBtn.classList.add('hidden');
                    
                    resultSection.classList.remove('hidden');
                });
            }
        });
        
        pauseUploadBtn.addEventListener('click', () => {
            if (uploader) {
                uploader.pause();
                pauseUploadBtn.classList.add('hidden');
                resumeUploadBtn.classList.remove('hidden');
                recordingStatus.textContent = '上传已暂停';
            }
        });
        
        resumeUploadBtn.addEventListener('click', () => {
            if (uploader) {
                uploader.resume();
                resumeUploadBtn.classList.add('hidden');
                pauseUploadBtn.classList.remove('hidden');
                recordingStatus.textContent = '正在上传...';
            }
        });
        
        cancelUploadBtn.addEventListener('click', () => {
            if (uploader) {
                uploader.abort();
                uploadProgressBar.style.width = '0%';
                uploadProgressText.textContent = '0%';
                
                // 重置按钮状态
                startUploadBtn.classList.remove('hidden');
                pauseUploadBtn.classList.add('hidden');
                resumeUploadBtn.classList.add('hidden');
                cancelUploadBtn.classList.add('hidden');
                
                // 显示取消消息
                resultSection.classList.remove('hidden');
                errorMessage.classList.remove('hidden');
                successMessage.classList.add('hidden');
                errorText.textContent = '上传已取消';
            }
        });
        
        // 工具函数：格式化文件大小
        function formatFileSize(bytes) {
            if (bytes < 1024) return bytes + ' B';
            else if (bytes < 1048576) return (bytes / 1024).toFixed(2) + ' KB';
            else return (bytes / 1048576).toFixed(2) + ' MB';
        }
    </script>
</body>
</html>
